Skip to content

fix(tool_runner): avoid leaking tool exception details to LLM (CWE-209) - #814

Open
andesyteoss wants to merge 1 commit into
RobotecAI:mainfrom
andesyteoss:fix/cwe209-tool-runner-exception-d885
Open

fix(tool_runner): avoid leaking tool exception details to LLM (CWE-209)#814
andesyteoss wants to merge 1 commit into
RobotecAI:mainfrom
andesyteoss:fix/cwe209-tool-runner-exception-d885

Conversation

@andesyteoss

@andesyteoss andesyteoss commented Jul 20, 2026

Copy link
Copy Markdown

Purpose

Fix a CWE-209 (Generation of Error Message Containing Sensitive Information) issue in ToolRunner where the raw exception object raised by a tool is embedded verbatim into the ToolMessage returned to the LLM/user.

Proposed Changes

In src/rai_core/rai/agents/langchain/core/tool_runner.py, when a tool invocation raises, the exception's stringified form was previously written into the ToolMessage.content field:

output = ToolMessage(
    content=f"Failed to run tool. Error: {e}",
    ...
)

Because the ToolMessage is returned to the caller (and typically fed back to the LLM and any downstream conversation surface), any sensitive data carried by the exception — file paths, stack traces, connection strings, credentials embedded in error messages by underlying libraries (e.g. psycopg2, requests, paramiko), internal IP addresses, ROS 2 topic/service internals — is exposed to whoever can observe the conversation.

This PR:

  1. Keeps full server-side visibility by switching the logger call from self.logger.info(...) to self.logger.exception(...), so operators still see the full traceback and message in logs (where it belongs).
  2. Replaces the returned content with a generic message that only reveals the tool name and the exception class name (e.g. RuntimeError, ValueError) — enough for the LLM to decide whether to retry or reformulate, but not enough to leak internals.

Issues

No pre-existing issue; filing this directly as a small security fix.

Testing

Reproduction (before the fix):

from unittest.mock import MagicMock
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from rai.agents.langchain.core.tool_runner import ToolRunner

@tool
def leaky_tool(x: str) -> str:
    """A tool that raises with a sensitive message."""
    raise RuntimeError("DB connection failed: postgres://admin:SUPER_SECRET_PW@10.0.0.5:5432/prod")

runner = ToolRunner(tools=[leaky_tool], logger=MagicMock())
ai_msg = AIMessage(
    content="",
    tool_calls=[{"name": "leaky_tool", "args": {"x": "hi"}, "id": "call_1", "type": "tool_call"}],
)
result = runner._func({"messages": [ai_msg]}, RunnableConfig())
print(result["messages"][0].content)
  • Before the fix: output contains SUPER_SECRET_PW, the internal host 10.0.0.5, and the DB name.
  • After the fix: output is Tool 'leaky_tool' failed with RuntimeError. Please try again or rephrase your request. — no secret, host, or connection string. The full traceback is still emitted via logger.exception, so operators lose no diagnostic information.

The change is confined to the exception branch of the tool-invocation loop; the success path and the existing ValidationError branch are untouched.

Security analysis

  • CWE: CWE-209 — Generation of Error Message Containing Sensitive Information.
  • Affected function: ToolRunner._func in src/rai_core/rai/agents/langchain/core/tool_runner.py (the generic except Exception as e: handler at what was line 107).
  • Data flow: tool raises → str(e) interpolated into ToolMessage.content → returned in the agent's message list → surfaced to the LLM prompt and any UI/log that renders the conversation.
  • Preconditions: (1) a tool invoked via ToolRunner raises an exception whose message contains sensitive data — common in practice because DB drivers, HTTP clients, and SSH libraries routinely embed hosts/paths/credentials in exception strings; (2) an attacker can observe LLM output (either directly as a user, or via a lower-privilege channel that receives the assistant's replies).
  • Mitigation: the returned content now discloses only the tool name (already known to the caller, since they invoked it) and the exception class name (a coarse taxonomy that does not leak values). Full detail is retained in server logs for debugging.

Adversarial review

Before submitting we tried to disprove this. We checked whether RunnableCallable or LangChain sanitises ToolMessage.content before returning it — it does not; the content is passed through unchanged. We checked whether there is a parallel error path elsewhere in ToolRunner that would still leak — the only other error branch handles ValidationError for tool-arg parsing and echoes the validator's message, which is bounded to schema info and not the same class of leak; it is out of scope for this patch. We considered whether preconditions already grant the attacker the leaked info — they don't: a user asking a robotics agent to call a database or perception tool does not, by that act, gain access to the DB's connection string. The finding stands.

Notes for reviewers

  • No public API changes; the ToolMessage shape is unchanged, only its content string is now generic.
  • If you'd prefer the returned content to omit the exception class name entirely, that's a one-line tweak — happy to adjust.

Exception messages raised by tool implementations were embedded verbatim
into the ToolMessage.content returned to the agent. Since ToolMessage
content is fed back into the LLM and, in Streamlit-based frontends,
rendered directly to the end user, raw exception text could disclose
sensitive internals (file paths, stack-trace fragments, connection
strings, credentials, internal hostnames/IPs) that an attacker can
trigger by intentionally causing tool failures.

Log full exception details server-side via logger.exception and return
a generic error message (including only the exception class name) to
the caller.
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.19%. Comparing base (e54f8ca) to head (b0ad885).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #814   +/-   ##
=======================================
  Coverage   73.19%   73.19%           
=======================================
  Files          82       82           
  Lines        3582     3582           
=======================================
  Hits         2622     2622           
  Misses        960      960           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@Bartok9

Bartok9 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Thanks @sebastiondev — solid CWE-209 catch.

I opened a salvage with your approach rebased on current main plus offline regression tests so the secret/host leak stays locked:

#823

Happy if maintainers prefer to land #814 directly and port the tests either way — credit remains yours either path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants